How to use Redux Toolkit for React + Typescript projects.
One of the bottlenecks using Redux is the number of boilerplates that need to be written: State, Action, Selector, and Reducer. This time, I tried using Redux Toolkit, a Redux helper library, and it helped me to use Redux easier and simpler than using regular Redux. In this article, I summarized how to use Redux Toolkit in a React project.
What is the Redux Toolkit?
The main maintainer of Redux and author of the Redux Toolkit, Mark Erikson, writes that he designed this tool with the following intentions.
1. make it easier to get started with Redux. 2. simplify common Redux tasks and code. 3. use opinionated defaults to guide you to "best practices 4. provide solutions to reduce or eliminate "boilerplate" concerns in using Redux.
Reference: Idiomatic Redux: Redux Toolkit 1.0
The use of more abstract functions reduces the overall amount of code, lowering the hurdle to using Redux, as well as including common libraries such as redux-thunk for writing asynchronous processing and redux-thunk for viewing Store status from Chrome's console. redux-devtools](https://github.com/zalmoxisus/redux-devtools-extension) and reselect to view the Store status from the Chrome console.
In addition, the Redux Toolkit has the concept of Slicer, which creates the corresponding action types and reducers bypassing the Reducer function and initialState value to it. It abstracts the commonly required code and reduces the amount of code that the developer needed to in regular Redux development.
Getting Started
In this article, we will create a simple application that retrieves user information from the LINE API and displays it on the screen, using React project template created with create-react-app
and Redux Toolkit.
Create a new React project with create-react-app
Create a project skeleton by specifying a Typescript with create-react-app command.
npx create-react-app redux-toolkit-sample --typescript cd redux-toolkit-sample
Add Redux Toolkit to your project
# NPM npm install @reduxjs/toolkit # Yarn yarn add @reduxjs/toolkit
Add LIFF SDK
Add @line/liff in your project that is required to use LINE Login feature of LINE API.
yarn add @line/liff
Add a Channel in the LINE Developers Console
You will need to create a channel in the LINE Developers Console.
Specify both openid
and profile
for Scope. Since we are going to open the application from the local environment, we specify https://localhost:3000
as the endpoint URL.
Save the LIFF ID in the env.development.local
file as follows, since we will refer to it later in the application.
REACT_APP_LIFF_ID="<YOUR_LIFF_ID>"
For more information on creating a LINE channel, please refer to this article.
As for the LINE API, we are only using it for the purpose of retrieving values from third-party APIs and storing them in the Redux Store, so if you want to try using a different API, this task is not required.
Create a Redux Store
Create store.ts
under src
. The store named auth will hold the data retrieved from the LINE API.
import {configureStore} from "@reduxjs/toolkit"; import {useSelector as rawUseSelector, TypedUseSelectorHook} from "react-redux"; import {authSlice} from "./slices/auth"; export const store = configureStore({ reducer: { auth: authSlice.reducer, }, }); // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType<typeof store.getState>; // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} export type AppDispatch = typeof store.dispatch; export const useSelector: TypedUseSelectorHook<RootState> = rawUseSelector;
The last line, useSelector
defines a custom version of useSelector
in Redux based on this article. By setting a custome useSelector, the State type auto-completion from the store will be available.
Create Slice
Next, create the slices
directory and create auth.ts
file as follows.
import {createAsyncThunk, createSlice, SerializedError} from "@reduxjs/toolkit"; import liff from "@line/liff"; const liffId = process.env.REACT_APP_LIFF_ID; export interface AuthState { liffIdToken?: string; userId?: string; displayName?: string; pictureUrl?: string; statusMessage?: string; error?: SerializedError; } const initialState: AuthState = { liffIdToken: undefined, userId: undefined, displayName: undefined, pictureUrl: undefined, statusMessage: undefined, error: undefined, }; interface LiffIdToken { liffIdToken?: string; } interface LINEProfile { userId?: string; displayName?: string; pictureUrl?: string; statusMessage?: string; } // LINE Login export const getLiffIdToken = createAsyncThunk<LiffIdToken>( "liffIdToken/fetch", async (): Promise<LiffIdToken> => { if (!liffId) { throw new Error("liffId is not defined"); } await liff.init({liffId}); if (!liff.isLoggedIn()) { // set `redirectUri` to redirect the user to a URL other than the endpoint URL of your LIFF app. liff.login(); } const liffIdToken = liff.getIDToken(); if (liffIdToken) { return {liffIdToken} as LiffIdToken; } throw new Error("LINE login error"); }, ); // Get LINE Profile export const getLINEProfile = createAsyncThunk<LINEProfile>( "lineProfile/fetch", async (): Promise<LINEProfile> => { const lineProfile = liff.getProfile(); if (lineProfile) { return lineProfile as LINEProfile; } throw new Error("LINE profile data fetch error"); }, ); export const authSlice = createSlice({ name: "auth", initialState, reducers: {}, extraReducers: (builder) => { builder.addCase(getLiffIdToken.fulfilled, (state, action) => { state.liffIdToken = action.payload.liffIdToken; }); builder.addCase(getLiffIdToken.rejected, (state, action) => { state.error = action.error; }); builder.addCase(getLINEProfile.fulfilled, (state, action) => { state.userId = action.payload.userId; state.displayName = action.payload.displayName; state.pictureUrl = action.payload.pictureUrl; state.statusMessage = action.payload.statusMessage; }); builder.addCase(getLINEProfile.rejected, (state, action) => { state.error = action.error; }); }, });
The tokens obtained from the LINE Login API and the LINE Profile information are all stored in one store, but I think it's fine to separate them here for better inplementation.
The redux-toolkit includes Immer, so you don't need to write anything like the following.
* Sample code writing without Immer
builder.addCase(getLINEProfile.fulfilled, (state, action) => { return { ...state, userId: action.payload.userId, displayName: action.payload.displayName, pictureUrl: action.payload.pictureUrl, statusMessage: action.payload.statusMessage, }; });
Add Selectors
Define Selectors to get the specific data that you need from the Redux Store using reselect
. reselect is already included in redux-toolkit, so there is no need to install it separately.
import {createSelector} from "reselect"; import {RootState} from "../store"; // get whole auth state export const authSelector = (state: RootState) => state.auth; /** * get liffIdToken * @returns liffIdToken */ export const liffIdTokenSelector = createSelector(authSelector, (auth) => { return auth.liffIdToken; }); /** * get LINEdisplay name * @returns displayName */ export const displayNameSelector = createSelector(authSelector, (auth) => { return auth.displayName; }); /** * get LINEimage url * @returns pictureUrl */ export const pictureUrlSelector = createSelector(authSelector, (auth) => { return auth.pictureUrl; }); /** * get error data * @returns error */ export const errorSelector = createSelector(authSelector, (auth) => { return auth.error; });
By properly defining the Selector with reselect according to the information you want, the Selector function will recalculate and re-render the component only when the action is dispatched and the necessary information is updated.
Add a component and call the external API
Add a component to display the LINE displayName and the image, and cut out the part of the logic that interacts with Redux into a Container.
components/Home/index.tsx
import {SerializedError} from "@reduxjs/toolkit"; import React, {useEffect, VFC} from "react"; interface Props { liffIdToken?: string; displayName?: string; pictureUrl?: string; error?: SerializedError; lineLogin: () => void; lineProfile: () => void; } export const Home: VFC<Props> = (props: Props) => { const {liffIdToken, displayName, pictureUrl, lineLogin, lineProfile, error} = props; useEffect(() => { // LINE Login if (!liffIdToken) { // liffIdToken がReduxに取得できていない場合LINE Login画面に戻る lineLogin(); } }, [liffIdToken, lineLogin]); useEffect(() => { if (liffIdToken) { // LINE Profile情報を取得 lineProfile(); } }, [liffIdToken, lineProfile]); if (error) { return ( <div> <p>ERROR!</p> </div> ); } else return ( <div className="App"> <header className="App-header"> <img src={pictureUrl} alt="line profile" width="80" height="80" /> <p>HELLO, {displayName}</p> </header> </div> ); };
containers/Home/index.tsx
import React, {FC, useCallback} from "react"; import {useDispatch} from "react-redux"; import {useSelector} from "./../store"; import {Home} from "../components/Home"; import {getLiffIdToken, getLINEProfile} from "../slices/auth"; import { displayNameSelector, errorSelector, liffIdTokenSelector, pictureUrlSelector, } from "../selectors/auth"; export const HomeContainer: FC = () => { const dispatch = useDispatch(); const liffIdToken = useSelector(liffIdTokenSelector); const displayName = useSelector(displayNameSelector); const pictureUrl = useSelector(pictureUrlSelector); const error = useSelector(errorSelector); const lineLogin = useCallback(() => { dispatch(getLiffIdToken()); }, [dispatch]); const lineProfile = useCallback(() => { dispatch(getLINEProfile()); }, [dispatch]); return ( <Home liffIdToken={liffIdToken} displayName={displayName} pictureUrl={pictureUrl} lineLogin={lineLogin} lineProfile={lineProfile} error={error} /> ); };
Lastly, update App.tsx
as below.
import React from "react"; import {HomeContainer as Home} from "../src/container/Home"; import "./App.css"; function App() { return ( <div className="App"> <Home /> </div> ); } export default App;
Check the Redux Store in redux-devtool in Chrome console
If you have redux-devtools installed in Chrome, you can check the status of the store from the console. liff.init()
and liff.getProfile()
requests are successful, the data will be stored in the Redux store.
If the API request fails, there will be an error stored instead as below.
Whole project file structure
The whole project's file structure is shown in below.
redux-toolkit-sample ├── node_modules │ └── ... ├── public │ └── ... ├── src │ ├── components │ └── Home/index.tsx │ ├── containers │ └── Home.tsx │ ├── selectors │ └── auth.ts │ ├── slices │ └── auth.ts │ ├── App.tsx │ ├── App.css │ ├── index.tsx │ └── store.ts ├── .env.development.local ├── .gitignore ├── package.json ├── README.md ├── yarn.lock
About the file structure
Since the concept of Slice has been added by using Redux-Toolkit, the file structure is a bit of a problem. This time, I didn't give much thought to it, but it might have been easier to understand if I had used Ducks or re-ducks format.
Summary
The Redux ToolKit has made it easier for me to think about using Redux in the early stages of a project.
References
- Getting Started with Redux Toolkit
- Idiomatic Redux: Redux Toolkit 1.0
- TypeScript で React をやるときは、小さいアプリでも Redux を最初から使ってもいいかもねというお話
Original Article in Japanese: React + Typescript プロジェクトに Redux Toolkit を導入したので使い方をざっくりとまとめてみる